상태 코드 401과 403을 구분해서 사용하기

상태 코드 401과 403을 구분해서 사용하기

한눈에 보기

401은 유효한 인증 자격 증명이 없음을 뜻하고 보통 재인증 흐름으로 이어진다. 403은 사용자를 식별했지만 해당 리소스나 동작을 허용하지 않는 경우다.

목차

왜 이 문제가 생기는가

인증 토큰이 만료된 사용자와 로그인은 했지만 관리자 기능을 사용할 수 없는 사용자에게 모두 403을 반환하면 프론트엔드는 어떻게 대응해야 할까. 전자는 로그인 갱신을 시도할 수 있지만, 후자는 갱신해도 권한이 생기지 않는다. 반대로 모든 권한 실패를 401로 반환하면 권한 없는 사용자가 반복해서 로그인 화면으로 튕기는 경험을 만든다.

상태 코드는 단순한 숫자가 아니라 서버가 실패의 종류를 클라이언트와 운영 도구에 전달하는 계약이다. 401과 403을 구분하려면 먼저 인증과 인가의 경계를 명확히 해야 한다.

이 글의 예제에 관하여

문서 편집 API와 사용자 정보는 권한 처리 흐름을 설명하기 위해 만든 가상 예제다. 실제 프로젝트의 권한 규칙을 사용하지 않았다.

인증과 인가부터 구분하기

flowchart TD
    R[요청 수신] --> A{유효한 자격 증명이 있는가?}
    A -->|아니오| U[401]
    A -->|예| P{이 작업을 허용하는가?}
    P -->|예| OK[업무 처리]
    P -->|아니오| H{존재를 숨겨야 하는가?}
    H -->|아니오| F[403]
    H -->|예| N[404 고려]

권한 검사는 역할 이름 하나만 확인하는 작업이 아닐 수 있다. 문서 소유자, 팀 구성원, 조직 상태, 리소스의 현재 상태 같은 조건이 함께 들어간다. 이 판단 결과를 상태 코드로 일관되게 매핑해야 한다.

401 Unauthorized의 의미

401은 요청에 유효한 인증 자격 증명이 없다는 뜻이다. 토큰이 없거나, 형식이 잘못되었거나, 만료되었거나, 검증에 실패한 경우가 여기에 해당한다.

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="documents", error="invalid_token"
Content-Type: application/problem+json

{
  "type": "https://errors.example.test/authentication-required",
  "title": "Authentication required",
  "status": 401,
  "code": "AUTHENTICATION_REQUIRED"
}

HTTP 인증 체계를 사용한다면 WWW-Authenticate 헤더로 필요한 인증 방식과 실패 정보를 표현한다. 응답 본문에는 클라이언트가 분기할 안정적인 오류 코드를 두되, 토큰 검증의 내부 상세나 사용자 존재 여부를 과도하게 노출하지 않는다.

401을 받은 클라이언트는 상황에 따라 다음 행동을 할 수 있다.

  1. 갱신 토큰으로 access token을 한 번 갱신한다.
  2. 원 요청을 새 자격 증명으로 한 번 재시도한다.
  3. 갱신도 실패하면 인증 상태를 정리하고 로그인 화면으로 이동한다.

모든 401에서 무조건 같은 요청을 재시도하면 갱신 요청 자체가 401인 경우 무한 루프가 생길 수 있다.

403 Forbidden의 의미

403은 서버가 요청을 이해했고 주체를 식별했지만 해당 작업을 허용하지 않는 경우다. 같은 자격 증명으로 다시 보내도 권한이나 리소스 상태가 바뀌지 않는 한 결과가 달라지지 않는다.

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type": "https://errors.example.test/permission-denied",
  "title": "Permission denied",
  "status": 403,
  "code": "DOCUMENT_EDIT_FORBIDDEN"
}

대표적인 예는 다음과 같다.

프론트엔드는 403을 받았다고 로그인 화면으로 보내기보다 권한 안내를 표시하거나 허용된 화면으로 돌아가게 하는 편이 자연스럽다.

404로 리소스 존재를 숨기는 경우

공개되어서는 안 되는 리소스의 존재 여부 자체가 정보일 수 있다. 다음 두 응답이 다르면 공격자는 문서 ID를 바꿔 가며 어떤 ID가 실제로 존재하는지 추측할 수 있다.

없는 문서       → 404
존재하지만 비공개 → 403

이런 위협을 줄이기 위해 권한 없는 사용자에게도 404 Not Found를 반환하는 정책을 선택할 수 있다.

const document = await documentRepository.findById(documentId);

if (!document || !canRead(actor, document)) {
  throw new NotFoundError("DOCUMENT_NOT_FOUND");
}

다만 모든 403을 404로 바꾸면 권한 설정 오류를 사용자에게 설명하기 어렵고 운영 분석도 흐려진다. “멤버이지만 편집 권한만 없음”처럼 리소스 존재를 이미 아는 사용자에게는 403이 더 적절할 수 있다. API별 위협 모델과 제품 경험을 기준으로 일관된 정책을 정한다.

상태 코드만 바꿔도 정보가 완전히 숨겨지는 것은 아니다

응답 시간, 본문 크기, 로그 노출, 목록 API 등 다른 경로로 존재 여부가 드러날 수 있다. 존재 은닉은 전체 조회 경로를 함께 검토해야 한다.

서버 처리 순서를 코드로 표현하기

인증과 인가를 섞어 한 조건문에 넣으면 어떤 실패인지 구분하기 어려워진다.

// 좋지 않은 예: 실패 원인과 정책이 한 조건에 섞인다.
if (!request.user || request.user.role !== "editor") {
  return response.status(403).end();
}

단계를 나누면 상태 코드와 테스트 조건이 명확해진다.

type Actor = {
  id: string;
  roles: string[];
};

async function updateDocument(request, response) {
  const actor = await authenticateBearerToken(request.headers.authorization);

  if (!actor) {
    response.setHeader("WWW-Authenticate", 'Bearer realm="documents"');
    return response.status(401).json({
      code: "AUTHENTICATION_REQUIRED",
    });
  }

  const document = await documentRepository.findById(request.params.id);

  if (!document) {
    return response.status(404).json({ code: "DOCUMENT_NOT_FOUND" });
  }

  if (!canEditDocument(actor, document)) {
    return response.status(403).json({ code: "DOCUMENT_EDIT_FORBIDDEN" });
  }

  const updated = await documentService.update(document, request.body);
  return response.status(200).json(updated);
}

실제 애플리케이션에서는 인증 middleware, guard, policy 계층으로 분리할 수 있다. 중요한 것은 프레임워크 이름보다 “주체 확인 → 리소스 조회 → 권한 판단 → 업무 처리”의 책임과 실패 의미가 보이는 구조다.

클라이언트는 상태 코드에 따라 다르게 대응한다

다음 interceptor는 개념을 보여 주기 위한 단순 예다.

async function requestWithAuth(input: RequestInfo, init?: RequestInit) {
  let response = await fetch(input, init);

  if (response.status === 401 && !isTokenRefreshRequest(input)) {
    const refreshed = await refreshAccessTokenOnce();
    if (refreshed) response = await fetch(input, withLatestToken(init));
  }

  if (response.status === 403) {
    showPermissionMessage();
  }

  return response;
}

동시에 여러 API가 401을 받으면 각 요청이 토큰 갱신을 시작하지 않도록 single-flight 형태로 갱신 작업 하나를 공유해야 한다. 또한 POST를 자동 재시도하려면 멱등성을 별도로 보장해야 한다.

상태 클라이언트의 일반적 대응 같은 요청 자동 재시도
401 자격 증명 갱신 또는 로그인 갱신 성공 후 제한적으로
403 권한 안내, 허용 화면 이동 보통 의미 없음
404 없는 리소스 안내 보통 의미 없음
429 대기 후 재시도 Retry-After와 멱등성 확인

응답 본문과 로그에 남길 정보

사용자에게 보여 줄 메시지와 운영 로그는 목적이 다르다. API 응답에는 안정적인 기계 판독 코드와 안전한 설명을 두고, 서버 로그에는 내부 상관관계와 판단 근거를 남긴다.

{
  "status": 403,
  "code": "DOCUMENT_EDIT_FORBIDDEN",
  "message": "이 문서를 수정할 권한이 없습니다.",
  "traceId": "trace-example-901"
}

로그에는 actorId, resourceType, action, policyResult, traceId 등을 구조화해 남길 수 있다. 원본 access token, 세션 ID, 개인정보는 로그에 기록하지 않는다. 401 급증은 토큰 만료 설정이나 배포 문제일 수 있고, 403 급증은 역할 정책 변경이나 프론트엔드 노출 오류일 수 있으므로 지표도 분리한다.

테스트해야 할 권한 행렬

권한 테스트는 성공 한 건과 실패 한 건만으로 부족하다.

사용자 상태 리소스 상태 기대 결과
인증 없음 존재함 401
유효하지 않은 토큰 존재함 401
소유자 존재함 200
읽기 전용 멤버 존재함 403
관련 없는 사용자 비공개 리소스 정책에 따라 404
관리자 삭제된 리소스 404 또는 도메인 정책
API 권한 점검

  • 401 응답에 적절한 인증 challenge가 있는가?
  • 403에서 토큰 갱신 루프가 발생하지 않는가?
  • 존재 은닉 정책이 목록과 상세 API에서 일관적인가?
  • 응답 본문이 내부 역할 구조를 과도하게 노출하지 않는가?
  • 권한 거부 로그에 비밀 값이 남지 않는가?

401은 유효한 인증 자격 증명이 없음을 뜻하고 보통 재인증 흐름으로 이어진다. 403은 사용자를 식별했지만 해당 리소스나 동작을 허용하지 않는 경우다.

결론

401은 유효한 인증 자격 증명이 없는 인증 실패이고, 403은 식별된 주체에게 작업 권한이 없는 인가 실패다. 리소스 존재를 숨겨야 하는 경우에는 404 정책을 별도로 정할 수 있다. 이 구분이 서버 코드, 클라이언트 대응, 오류 본문, 로그와 지표까지 일관되게 이어져야 로그인 루프를 막고 권한 문제도 정확히 분석할 수 있다.

관련 노트